import LngLatBounds from './lng_lat_bounds';

/**
 * constrain n to the given range, excluding the minimum, via modular arithmetic
 *
 * @param n value
 * @param min the minimum value to be returned, exclusive
 * @param max the maximum value to be returned, inclusive
 * @returns constrained number
 * @private
 */
function wrap(n: number, min: number, max: number): number {
  const d = max - min;
  const w = ((n - min) % d + d) % d + min;
  return (w === min) ? max : w;
}

/**
 * A `LngLat` object represents a given longitude and latitude coordinate, measured in degrees.
 *
 * Mapbox GL uses longitude, latitude coordinate order (as opposed to latitude, longitude) to match GeoJSON.
 *
 * Note that any Mapbox GL method that accepts a `LngLat` object as an argument or option
 * can also accept an `Array` of two numbers and will perform an implicit conversion.
 * This flexible type is documented as {@link LngLatLike}.
 *
 * @param {number} lng Longitude, measured in degrees.
 * @param {number} lat Latitude, measured in degrees.
 * @example
 * var ll = new mapboxgl.LngLat(-73.9749, 40.7736);
 * @see [Get coordinates of the mouse pointer](https://www.mapbox.com/mapbox-gl-js/example/mouse-position/)
 * @see [Display a popup](https://www.mapbox.com/mapbox-gl-js/example/popup/)
 * @see [Highlight features within a bounding box](https://www.mapbox.com/mapbox-gl-js/example/using-box-queryrenderedfeatures/)
 * @see [Create a timeline animation](https://www.mapbox.com/mapbox-gl-js/example/timeline-animation/)
 */
class LngLat {
  lng: number;
  lat: number;

  constructor(lng: number, lat: number) {
    if (isNaN(lng) || isNaN(lat)) {
      throw new Error(`Invalid LngLat object: (${lng}, ${lat})`);
    }
    this.lng = +lng;
    this.lat = +lat;
    if (this.lat > 90 || this.lat < -90) {
      throw new Error('Invalid LngLat latitude value: must be between -90 and 90');
    }
  }

  /**
   * Returns a new `LngLat` object whose longitude is wrapped to the range (-180, 180).
   *
   * @returns {LngLat} The wrapped `LngLat` object.
   * @example
   * var ll = new mapboxgl.LngLat(286.0251, 40.7736);
   * var wrapped = ll.wrap();
   * wrapped.lng; // = -73.9749
   */
  wrap() {
    return new LngLat(wrap(this.lng, -180, 180), this.lat);
  }

  /**
   * Returns the coordinates represented as an array of two numbers.
   *
   * @returns {Array<number>} The coordinates represeted as an array of longitude and latitude.
   * @example
   * var ll = new mapboxgl.LngLat(-73.9749, 40.7736);
   * ll.toArray(); // = [-73.9749, 40.7736]
   */
  toArray() {
    return [this.lng, this.lat];
  }

  /**
   * Returns the coordinates represent as a string.
   *
   * @returns {string} The coordinates represented as a string of the format `'LngLat(lng, lat)'`.
   * @example
   * var ll = new mapboxgl.LngLat(-73.9749, 40.7736);
   * ll.toString(); // = "LngLat(-73.9749, 40.7736)"
   */
  toString() {
    return `LngLat(${this.lng}, ${this.lat})`;
  }

  /**
   * Returns a `LngLatBounds` from the coordinates extended by a given `radius`.
   *
   * @param {number} [radius=0] Distance in meters from the coordinates to extend the bounds.
   * @returns {LngLatBounds} A new `LngLatBounds` object representing the coordinates extended by the `radius`.
   * @example
   * var ll = new mapboxgl.LngLat(-73.9749, 40.7736);
   * ll.toBounds(100).toArray(); // = [[-73.97501862141328, 40.77351016847229], [-73.97478137858673, 40.77368983152771]]
   */
  toBounds(radius: number = 0) {
    const earthCircumferenceInMetersAtEquator = 40075017;
    const latAccuracy = 360 * radius / earthCircumferenceInMetersAtEquator,
      lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat);

    return new LngLatBounds(new LngLat(this.lng - lngAccuracy, this.lat - latAccuracy),
      new LngLat(this.lng + lngAccuracy, this.lat + latAccuracy));
  }

  /**
   * Converts an array of two numbers or an object with `lng` and `lat` or `lon` and `lat` properties
   * to a `LngLat` object.
   *
   * If a `LngLat` object is passed in, the function returns it unchanged.
   *
   * @param {LngLatLike} input An array of two numbers or object to convert, or a `LngLat` object to return.
   * @returns {LngLat} A new `LngLat` object, if a conversion occurred, or the original `LngLat` object.
   * @example
   * var arr = [-73.9749, 40.7736];
   * var ll = mapboxgl.LngLat.convert(arr);
   * ll;   // = LngLat {lng: -73.9749, lat: 40.7736}
   */
  static convert(input: LngLatLike): LngLat {
    if (input instanceof LngLat) {
      return input;
    }
    if (Array.isArray(input) && (input.length === 2 || input.length === 3)) {
      return new LngLat(Number(input[0]), Number(input[1]));
    }
    if (!Array.isArray(input) && typeof input === 'object' && input !== null) {
      return new LngLat(
        // flow can't refine this to have one of lng or lat, so we have to cast to any
        Number('lng' in input ? input.lng : input.lon),
        Number(input.lat)
      );
    }
    throw new Error("`LngLatLike` argument must be specified as a LngLat instance, an object {lng: <lng>, lat: <lat>}, an object {lon: <lng>, lat: <lat>}, or an array of [<lng>, <lat>]");
  }
}

/**
 * A {@link LngLat} object, an array of two numbers representing longitude and latitude,
 * or an object with `lng` and `lat` or `lon` and `lat` properties.
 *
 * @typedef {LngLat | {lng: number, lat: number} | {lon: number, lat: number} | [number, number]} LngLatLike
 * @example
 * var v1 = new mapboxgl.LngLat(-122.420679, 37.772537);
 * var v2 = [-122.420679, 37.772537];
 * var v3 = {lon: -122.420679, lat: 37.772537};
 */
export type LngLatLike = LngLat | { lng: number, lat: number } | { lon: number, lat: number } | [number, number];

export default LngLat;
